Master 3D mesh creation and manipulation in Python using the numpy-stl library
# Install numpy-stl using pip
pip install numpy-stl
# Or with conda
conda install -c conda-forge numpy-stl
NumPy-STL is a Python library for working with STL (STereoLithography) files, commonly used in 3D printing and CAD applications.
STL files represent 3D surfaces as collections of triangular facets. Each facet is defined by three vertices and a normal vector.
An STL mesh consists of triangular faces. Each face has three vertices (3D points) and a normal vector (direction the face points).
from stl import mesh
import numpy as np
# Create a simple mesh with 1 triangular face
vertices = np.array([
[0, 0, 0], # Vertex 1
[1, 0, 0], # Vertex 2
[0, 1, 0] # Vertex 3
])
faces = np.array([[0, 1, 2]]) # Triangle uses vertices 0, 1, 2
# Create mesh object
triangle_mesh = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
for i, face in enumerate(faces):
for j in range(3):
triangle_mesh.vectors[i][j] = vertices[face[j]]
# Save to file
triangle_mesh.save('triangle.stl')
from stl import mesh
# Load an existing STL file
my_mesh = mesh.Mesh.from_file('model.stl')
# Inspect mesh properties
print(f"Number of faces: {len(my_mesh.vectors)}")
print(f"Mesh volume: {my_mesh.get_mass_properties()[0]}")
print(f"Center of gravity: {my_mesh.get_mass_properties()[1]}")
Let's create basic 3D shapes from scratch. These primitives are the foundation for complex models.
import numpy as np
from stl import mesh
def create_cube(size=1.0, center=(0, 0, 0)):
"""Create a cube mesh with specified size and center"""
half = size / 2
cx, cy, cz = center
# Define 8 vertices of the cube
vertices = np.array([
[cx - half, cy - half, cz - half], # 0: bottom-left-back
[cx + half, cy - half, cz - half], # 1: bottom-right-back
[cx + half, cy + half, cz - half], # 2: top-right-back
[cx - half, cy + half, cz - half], # 3: top-left-back
[cx - half, cy - half, cz + half], # 4: bottom-left-front
[cx + half, cy - half, cz + half], # 5: bottom-right-front
[cx + half, cy + half, cz + half], # 6: top-right-front
[cx - half, cy + half, cz + half] # 7: top-left-front
])
# Define 12 triangular faces (2 per cube face)
faces = np.array([
[0, 3, 1], [1, 3, 2], # Back face
[4, 5, 7], [5, 6, 7], # Front face
[0, 1, 5], [0, 5, 4], # Bottom face
[2, 3, 7], [2, 7, 6], # Top face
[0, 4, 7], [0, 7, 3], # Left face
[1, 2, 6], [1, 6, 5] # Right face
])
# Create mesh
cube = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
for i, face in enumerate(faces):
for j in range(3):
cube.vectors[i][j] = vertices[face[j]]
return cube
# Create and save a cube
my_cube = create_cube(size=20)
my_cube.save('cube.stl')
def create_cylinder(radius=1.0, height=2.0, segments=32, center=(0, 0, 0)):
"""Create a cylinder mesh with circular cross-section"""
cx, cy, cz = center
vertices = []
# Create vertices for bottom and top circles
for z_offset in [-height/2, height/2]:
for i in range(segments):
angle = 2 * np.pi * i / segments
x = cx + radius * np.cos(angle)
y = cy + radius * np.sin(angle)
z = cz + z_offset
vertices.append([x, y, z])
# Add center points for top and bottom caps
vertices.append([cx, cy, cz - height/2]) # Bottom center
vertices.append([cx, cy, cz + height/2]) # Top center
vertices = np.array(vertices)
faces = []
# Side faces and caps...
for i in range(segments):
next_i = (i + 1) % segments
faces.append([i, next_i, segments + i])
faces.append([next_i, segments + next_i, segments + i])
faces = np.array(faces)
cylinder = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
for i, face in enumerate(faces):
for j in range(3):
cylinder.vectors[i][j] = vertices[face[j]]
return cylinder
def create_sphere(radius=1.0, lat_segments=16, lon_segments=32, center=(0, 0, 0)):
"""Create a UV sphere mesh"""
cx, cy, cz = center
vertices = []
# Create vertices using spherical coordinates
for i in range(lat_segments + 1):
theta = np.pi * i / lat_segments # 0 to π (top to bottom)
for j in range(lon_segments):
phi = 2 * np.pi * j / lon_segments # 0 to 2π (around)
x = cx + radius * np.sin(theta) * np.cos(phi)
y = cy + radius * np.sin(theta) * np.sin(phi)
z = cz + radius * np.cos(theta)
vertices.append([x, y, z])
vertices = np.array(vertices)
faces = []
# Create triangular faces
for i in range(lat_segments):
for j in range(lon_segments):
first = i * lon_segments + j
second = first + lon_segments
faces.append([first, second, first + 1])
faces.append([second, second + 1, first + 1])
faces = np.array(faces) % len(vertices)
sphere = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
for i, face in enumerate(faces):
for j in range(3):
sphere.vectors[i][j] = vertices[face[j]]
return sphere
Parametric modeling allows you to create shapes defined by mathematical functions and parameters that can be easily adjusted.
Instead of defining each vertex manually, parametric models use equations to generate geometry. Change a parameter (like radius or height), and the entire model updates automatically.
def parametric_torus(major_radius=20, minor_radius=5, segments=32, rings=16):
"""Create a torus (donut shape) using parametric equations"""
vertices = []
for i in range(rings):
v = 2 * np.pi * i / rings # Angle around major circle
for j in range(segments):
u = 2 * np.pi * j / segments # Angle around minor circle
# Parametric equations for torus
x = (major_radius + minor_radius * np.cos(u)) * np.cos(v)
y = (major_radius + minor_radius * np.cos(u)) * np.sin(v)
z = minor_radius * np.sin(u)
vertices.append([x, y, z])
# Create faces by connecting vertices...
return torus
Let's combine multiple primitives to create a complete 3D model using spheres positioned at different heights.
def build_snowman():
"""Create a snowman from three spheres"""
# Bottom sphere (largest)
bottom = create_sphere(radius=15, lat_segments=32, lon_segments=64, center=(0, 0, 15))
# Middle sphere (medium)
middle = create_sphere(radius=11, lat_segments=32, lon_segments=64, center=(0, 0, 38))
# Top sphere (head - smallest)
head = create_sphere(radius=8, lat_segments=32, lon_segments=64, center=(0, 0, 56))
# Eyes and nose
left_eye = create_sphere(radius=1, lat_segments=8, lon_segments=16, center=(-3, 6, 58))
right_eye = create_sphere(radius=1, lat_segments=8, lon_segments=16, center=(3, 6, 58))
# Combine all meshes
snowman = mesh.Mesh(np.concatenate([
bottom.data, middle.data, head.data,
left_eye.data, right_eye.data
]))
return snowman
snowman = build_snowman()
snowman.save('snowman.stl')
Use np.concatenate() to merge multiple mesh.data arrays into a single model.
Create functional mechanical gears with customizable teeth count, size, and thickness.
def create_gear(teeth=20, outer_radius=20, inner_radius=5, thickness=5, tooth_depth=3):
"""Generate a gear with specified parameters"""
vertices = []
root_radius = outer_radius - tooth_depth
# Generate gear profile vertices
for side in [0, thickness]:
vertices.append([0, 0, side]) # Center point
for i in range(teeth * 4):
angle = 2 * np.pi * i / (teeth * 4)
# Alternate between root and outer radius for teeth
r = root_radius if i % 4 in [0, 3] else outer_radius
x = r * np.cos(angle)
y = r * np.sin(angle)
vertices.append([x, y, side])
vertices = np.array(vertices)
# Create faces connecting the vertices...
# (face generation code here)
gear = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
for i, face in enumerate(faces):
for j in range(3):
gear.vectors[i][j] = vertices[face[j]]
return gear
# Create gears with different sizes
gear_20t = create_gear(teeth=20, outer_radius=25)
gear_40t = create_gear(teeth=40, outer_radius=50)
Design a functional phone stand with adjustable dimensions for different device sizes.
def create_phone_stand(phone_width=80, phone_thickness=10, angle=60):
"""Create a phone stand with customizable dimensions"""
# Base
base_width = phone_width + 20
base = create_cube(size=1)
base.x *= base_width
base.y *= 80
base.z *= 5
base.translate([0, 0, 2.5])
# Back support
back_support = create_cube(size=1)
back_support.x *= base_width
back_support.y *= 5
back_support.z *= 100
back_support.rotate([1, 0, 0], np.radians(angle - 90))
back_support.translate([0, -35, 50])
# Phone groove
groove = create_cube(size=1)
groove.x *= phone_width + 2
groove.y *= phone_thickness + 2
groove.z *= 50
groove.translate([0, 20, 15])
# Combine parts
phone_stand = mesh.Mesh(np.concatenate([
base.data, back_support.data, groove.data
]))
return phone_stand
stand = create_phone_stand(phone_width=75, angle=65)
stand.save('phone_stand.stl')
Create elegant vases using parametric curves to vary the radius along the height.
def create_curved_vase(height=100, segments=64, vertical_segments=50):
"""Create a vase with varying radius using a curve"""
vertices = []
for v_seg in range(vertical_segments + 1):
t = v_seg / vertical_segments # Height from 0 to 1
z = t * height
# Vary radius using sine wave for elegant curves
base_radius = 15
variation = 10 * np.sin(np.pi * t)
radius = base_radius + variation
# Create circle at this height
for seg in range(segments):
angle = 2 * np.pi * seg / segments
x = radius * np.cos(angle)
y = radius * np.sin(angle)
vertices.append([x, y, z])
vertices = np.array(vertices)
# Create faces connecting the rings...
# (face generation code)
vase = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
for i, face in enumerate(faces):
for j in range(3):
vase.vectors[i][j] = vertices[face[j]]
return vase
elegant_vase = create_curved_vase(height=120, segments=96)
elegant_vase.save('vase.stl')
Create decorative patterns using hexagonal grids for lightweight structural designs.
def create_honeycomb_plate(width=100, depth=100, hex_radius=8, wall_thickness=3):
"""Create a plate with honeycomb pattern"""
all_vertices = []
all_faces = []
vertex_offset = 0
# Calculate hexagon spacing
h_spacing = hex_radius * 1.5
v_spacing = hex_radius * np.sqrt(3)
# Create hexagonal grid
rows = int(depth / v_spacing) + 2
cols = int(width / h_spacing) + 2
for row in range(rows):
for col in range(cols):
# Offset every other row for honeycomb
x_offset = h_spacing * 1.5 if row % 2 == 1 else 0
x = col * h_spacing * 2 + x_offset - width / 2
y = row * v_spacing - depth / 2
if abs(x) < width / 2 and abs(y) < depth / 2:
vertices, faces = create_hexagon(hex_radius, wall_thickness, x, y)
all_vertices.append(vertices)
all_faces.append(faces + vertex_offset)
vertex_offset += len(vertices)
# Combine all hexagons
all_vertices = np.vstack(all_vertices)
all_faces = np.vstack(all_faces)
honeycomb = mesh.Mesh(np.zeros(all_faces.shape[0], dtype=mesh.Mesh.dtype))
for i, face in enumerate(all_faces):
for j in range(3):
honeycomb.vectors[i][j] = all_vertices[face[j]]
return honeycomb
honeycomb = create_honeycomb_plate(width=150, depth=150, hex_radius=5)
honeycomb.save('honeycomb.stl')
Ensure your 3D models are valid and ready for 3D printing by checking for common errors.
Non-manifold edges, holes, inverted normals, and intersecting faces can cause printing failures. Always validate before printing!
def validate_mesh(mesh_obj):
"""Comprehensive mesh validation"""
errors = []
warnings = []
# Check 1: Zero-area triangles
areas = mesh_obj.get_unit_normals()
zero_area_count = np.sum(np.all(areas == 0, axis=1))
if zero_area_count > 0:
errors.append(f"Found {zero_area_count} zero-area triangles")
# Check 2: NaN or infinite values
if np.any(np.isnan(mesh_obj.vectors)) or np.any(np.isinf(mesh_obj.vectors)):
errors.append("Mesh contains NaN or infinite values")
# Check 3: Negative volume (inverted normals)
volume, cog, inertia = mesh_obj.get_mass_properties()
if volume < 0:
warnings.append("Negative volume - normals may be inverted")
# Report results
if not errors and not warnings:
print("✓ Mesh validation passed!")
return True
if errors:
print("✗ ERRORS found:")
for error in errors:
print(f" - {error}")
return len(errors) == 0
def mesh_statistics(mesh_obj):
"""Print detailed mesh statistics"""
volume, cog, inertia = mesh_obj.get_mass_properties()
print("=== Mesh Statistics ===")
print(f"Triangular faces: {len(mesh_obj.vectors)}")
print(f"Volume: {volume:.2f} cubic units")
print(f"Center of Gravity: ({cog[0]:.2f}, {cog[1]:.2f}, {cog[2]:.2f})")
print(f"Bounding box X: [{mesh_obj.x.min():.2f}, {mesh_obj.x.max():.2f}]")
my_mesh = mesh.Mesh.from_file('model.stl')
validate_mesh(my_mesh)
mesh_statistics(my_mesh)
Manipulate meshes using translation, rotation, scaling, and mirroring operations.
Move the mesh in 3D space by adding offsets to coordinates
Rotate around any axis by a specified angle in radians
Resize uniformly or per-axis by multiplying coordinates
Reflect across a plane to create symmetrical designs
from stl import mesh
my_mesh = mesh.Mesh.from_file('cube.stl')
# Translate (move) the mesh
my_mesh.translate([10, 20, 5]) # +10 in X, +20 in Y, +5 in Z
# Alternative: Direct manipulation
my_mesh.x += 10
my_mesh.y += 20
my_mesh.z += 5
my_mesh.save('translated.stl')
import numpy as np
my_mesh = mesh.Mesh.from_file('model.stl')
# Rotate 45 degrees around Z-axis
my_mesh.rotate([0, 0, 1], np.radians(45))
# Rotate 90 degrees around X-axis
my_mesh.rotate([1, 0, 0], np.radians(90))
# Rotate around custom axis
axis = np.array([1, 1, 0])
axis = axis / np.linalg.norm(axis) # Normalize
my_mesh.rotate(axis, np.radians(30))
# Rotate around a specific point
center_point = np.array([10, 10, 10])
my_mesh.rotate([0, 0, 1], np.radians(45), point=center_point)
my_mesh = mesh.Mesh.from_file('model.stl')
# Uniform scaling (double the size)
my_mesh.vectors *= 2.0
# Non-uniform scaling (stretch/compress per axis)
my_mesh.x *= 2.0 # Double width
my_mesh.y *= 1.5 # 1.5x depth
my_mesh.z *= 0.5 # Half height
def mirror_mesh(mesh_obj, plane='XY'):
"""Mirror mesh across a plane"""
mirrored = mesh.Mesh.from_file('')
mirrored.data = mesh_obj.data.copy()
if plane == 'XY': # Mirror across XY plane (flip Z)
mirrored.z *= -1
elif plane == 'XZ': # Mirror across XZ plane (flip Y)
mirrored.y *= -1
elif plane == 'YZ': # Mirror across YZ plane (flip X)
mirrored.x *= -1
# Fix normals (flip triangle winding)
for i in range(len(mirrored.vectors)):
mirrored.vectors[i] = mirrored.vectors[i][[0, 2, 1]]
return mirrored
original = mesh.Mesh.from_file('model.stl')
mirrored = mirror_mesh(original, plane='XY')
# Combine for symmetrical models
symmetric = mesh.Mesh(np.concatenate([original.data, mirrored.data]))
symmetric.save('symmetric.stl')
Calculate the surface area of meshes for material estimation and cost analysis.
def calculate_surface_area(mesh_obj):
"""Calculate total surface area of the mesh"""
total_area = 0
for triangle in mesh_obj.vectors:
v0, v1, v2 = triangle # Three vertices
# Calculate edge vectors
edge1 = v1 - v0
edge2 = v2 - v0
# Area = 0.5 * |edge1 × edge2|
cross = np.cross(edge1, edge2)
area = 0.5 * np.linalg.norm(cross)
total_area += area
return total_area
def get_mesh_properties(mesh_obj, units='mm'):
"""Get comprehensive mesh measurements"""
surface_area = calculate_surface_area(mesh_obj)
volume, cog, inertia = mesh_obj.get_mass_properties()
# Bounding box dimensions
x_size = mesh_obj.x.max() - mesh_obj.x.min()
y_size = mesh_obj.y.max() - mesh_obj.y.min()
z_size = mesh_obj.z.max() - mesh_obj.z.min()
print(f"=== Mesh Properties ({units}) ===")
print(f"Dimensions: {x_size:.2f} × {y_size:.2f} × {z_size:.2f}")
print(f"Surface Area: {surface_area:.2f} {units}²")
print(f"Volume: {volume:.2f} {units}³")
print(f"Triangles: {len(mesh_obj.vectors)}")
return {
'surface_area': surface_area,
'volume': volume,
'dimensions': (x_size, y_size, z_size)
}
my_mesh = mesh.Mesh.from_file('model.stl')
props = get_mesh_properties(my_mesh)
Estimate 3D printing time based on model properties and printer settings.
def estimate_print_time(mesh_obj,
layer_height=0.2, # mm
print_speed=50, # mm/s
infill_density=20): # percentage
"""Estimate 3D printing time and material usage"""
volume, cog, inertia = mesh_obj.get_mass_properties()
surface_area = calculate_surface_area(mesh_obj)
height = mesh_obj.z.max() - mesh_obj.z.min()
# Calculate number of layers
num_layers = int(np.ceil(height / layer_height))
# Estimate perimeter length per layer
perimeter_per_layer = surface_area / height
# Wall printing time (3 perimeters)
wall_time = (perimeter_per_layer * 3 * num_layers) / print_speed
# Infill time
infill_volume = volume * (infill_density / 100)
infill_path = infill_volume / (layer_height * 0.4)
infill_time = infill_path / print_speed
# Travel time
travel_time = num_layers * 2
total_seconds = wall_time + infill_time + travel_time
hours = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
# Filament usage (1.75mm PLA, density 1.24 g/cm³)
filament_weight = (volume / 1000) * 1.24 * 1.05 # +5% waste
print(f"=== Print Time Estimation ===")
print(f"Estimated Time: {hours}h {minutes}m")
print(f"Layers: {num_layers}")
print(f"Filament: {filament_weight:.1f}g")
print(f"\nSettings: {layer_height}mm layers, {print_speed}mm/s, {infill_density}% infill")
return {
'hours': hours,
'minutes': minutes,
'filament_g': filament_weight,
'layers': num_layers
}
my_mesh = mesh.Mesh.from_file('model.stl')
result = estimate_print_time(my_mesh)
# Complete workflow: Create, validate, transform, analyze
# 1. Create a parametric model
my_part = create_cube(size=50)
# 2. Apply transformations
my_part.rotate([0, 0, 1], np.radians(45))
my_part.translate([0, 0, 10])
# 3. Validate the mesh
if validate_mesh(my_part):
print("✓ Mesh is valid")
# 4. Analyze properties
props = get_mesh_properties(my_part)
# 5. Estimate print time
print_info = estimate_print_time(my_part)
# 6. Save the final model
my_part.save('final_part.stl')
print("✓ Model saved")
You've learned the fundamentals of numpy-stl, from basic primitives to complex parametric designs. With these tools, you can create custom 3D models programmatically for automated manufacturing, rapid prototyping, and creative projects.
Start experimenting with your own designs! The combination of Python's programming power and 3D modeling opens endless possibilities.